[#267] Indexing recovery UI for failed publish#282
Conversation
localStorage-based intent persists content + metadata before wallet confirmation. If the indexer call fails after a successful on-chain tx, a recovery banner appears on next page load with retry/dismiss options. User-rejected transactions clear the intent immediately (no false recovery). 409/duplicate responses treated as idempotent success. Fixes #267 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: REQUEST CHANGES
Summary
The recovery flow is close, but it misses one of the issue's required failure modes: once the wallet submits a transaction, a later receipt-poll failure should still leave enough state for recovery.
Findings
- [high] Receipt-poll failures are not recoverable because
txHashis only persisted afterwaitForTransactionReceiptsucceeds.- File:
src/hooks/usePublish.ts:121 - Suggestion: Persist the transaction hash immediately after
writeContractAsyncreturns it, before awaiting receipt confirmation. Right now, if the wallet broadcast succeeds and receipt polling fails, the stored intent still hastxHash: null,loadPendingIntent()filters it out, and the recovery banner never appears. That violates issue #267's explicit requirement for the "tx sent but receipt poll failed" path.
- File:
Decision
Requesting changes because this leaves a permanent gap in the recovery mechanism for one of the documented acceptance paths.
project7-interns
left a comment
There was a problem hiding this comment.
T2b Review — REQUEST CHANGES
Overall approach is solid. The intent lifecycle and error classification address T2a's PR #280 feedback well. Three issues to fix:
1. RecoveryBanner.tsx:25-28 — Missing try/finally in handleRetry
If onRetry throws (not just returns { success: false }), setRetrying is never set back to false, permanently disabling both buttons.
async function handleRetry() {
setRetrying(true);
try {
await onRetry();
} finally {
setRetrying(false);
}
}2. useChainPlot.ts:41 — Unstable object reference in dependency array
intentCallbacks is an object literal created on every render of the parent component, so chainPlot is recreated every render despite all individual callbacks being stable (useCallback). Either destructure into individual params or useMemo the callbacks object in the parent.
3. usePublish.ts:147 — onIndexed?.() to clear intent on user rejection is semantically misleading
Calling onIndexed when no indexing happened makes the callback contract confusing. Consider adding a dedicated onIntentClear callback, or at minimum rename the existing callback to something like onComplete that covers both success and rejection-clear cases. Current code works but will confuse future readers.
Items 1 and 2 are bugs. Item 3 is a strong suggestion — won't block on it if you have a reason to keep it.
…lling Fixes T2a review: if receipt polling fails after wallet broadcasts, the intent now has the tx hash and recovery banner will appear. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Follow-up blocking finding after the receipt-poll fix:
- [high]
src/hooks/usePublish.ts:132still treats indexer HTTP failures as success because thefetch()response is ignored. A500/400response will fall through toonIndexed?.(), clear the saved intent, and set state topublished, so the recovery banner never appears. The issue explicitly requires 500/network failures to preserve recovery state, with only409treated as idempotent success. Please gate success onresponse.ok || response.status === 409and keep the intent on other statuses.
Only clear intent on 2xx or 409 (duplicate). Non-success responses now throw, preserving the intent for recovery. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Summary
The recovery flow now covers the required failure modes: tx hash is persisted immediately after broadcast, and indexer HTTP failures preserve the saved intent unless the response is successful or a 409.
Findings
- No blocking findings from this review pass.
Decision
Approving from my side. The implementation now matches the issue's recovery requirements; GitHub checks were still pending at the time of review.
project7-interns
left a comment
There was a problem hiding this comment.
T2b Re-review — APPROVED
T2a's findings (tx hash persistence timing, indexer response status) are both fixed correctly:
onTxConfirmednow fires afterwriteContractAsyncand beforewaitForTransactionReceipt— recovery works even if receipt polling fails- Indexer response is checked: only 2xx/409 clears intent, other statuses throw and preserve recovery state
My two items from the first review remain but are non-blocking:
RecoveryBanner.tsxhandleRetry missing try/finally —attemptRetryhas internal try/catch so won't throw in practiceuseChainPlot.tsunstableintentCallbacksobject in dep array — perf concern only, callbacks are lightweight
Ship it.
Summary
usePublishIntenthook: localStorage-based intent that persists content, metadata, tx hash, and indexer route across page reloadsusePublishto accept intent lifecycle callbacks (onIntentSave,onTxConfirmed,onIndexed) — user-rejected transactions clear intent immediately, all other errors preserve it for recoveryRecoveryBannercomponent: shows on create/chain pages when a pending intent exists, with retry (re-sends indexer POST) and dismiss options/create(storyline) and/chain(plot chaining) pagesKey design decisions
usePublish.ts: Only user-rejected tx clears the intent. Network errors, receipt poll failures, and indexer failures all preserve the intent for recovery (addresses T2a feedback on PR [#267] Indexing recovery UI for failed publish #280)useStateinit instead ofuseEffectfor loading pending intent (avoids React strict-mode lint rule)Test plan
/chain)npm run typecheckpassesnpm run lintpasses (no new warnings)Fixes #267
🤖 Generated with Claude Code